API
I. API Basics
1. Why a Separate BE + FE?
- Modern pattern: host BE (Express + DB) and FE (React) separately on different servers
- They talk to each other via JSON — not HTML like traditional server-rendered apps
- One backend can serve multiple frontends — a website, a mobile app, a desktop app all hitting the same API
- This separation is sometimes called the Jamstack pattern
- In Express, switching from serving HTML to serving JSON is as simple as using
res.json()instead ofres.render()
2. REST
REST = Representational State Transfer — a widely adopted convention for naming and organizing API endpoints
Without a convention, API routes can become inconsistent and hard to read:
/api/getAllPostComments— what verb does this imply? What if I want to create one?/api/savePostInDatabase— this names the action not the resource
REST fixes this by being resource-based — you name endpoints after the thing you're working with, and use HTTP verbs to express what you're doing to it.
HTTP Verbs and What They Mean
| Verb | Action | Example |
|---|---|---|
| GET | Read | GET /posts — fetch all posts |
| POST | Create | POST /posts — create a new post |
| PUT | Update | PUT /posts/:id — replace a post entirely |
| PATCH | Update | PATCH /posts/:id — update part of a post |
| DELETE | Delete | DELETE /posts/:id — delete a post |
REST URL Structure
REST APIs typically expose two URL shapes per resource — one for the whole collection, one for a single item:
GET /posts → all posts
GET /posts/:postId → one specific post
POST /posts → create a new post
PUT /posts/:postId → update a specific post
DELETE /posts/:postId → delete a specific post
You can also nest resources to express relationships:
GET /posts/:postId/comments → all comments on a specific post
GET /posts/:postId/comments/:commentId → one specific comment on that post
The URL itself tells you what you're working with at every level. Each segment narrows it down further.
Why Follow REST?
- Makes your API predictable — other developers can guess your endpoints
- Easier to maintain as the app grows
- Aligns with how HTTP was designed to work
- Industry standard — most APIs you've consumed (Giphy, weather APIs, etc.) follow it
3. CORS — Cross-Origin Resource Sharing
The Same Origin Policy
Browsers enforce a security rule: a web page can only make requests to the same origin (same domain + port + protocol) that served it. This prevents a malicious site from silently making requests to another site using your cookies.
For example, if your frontend is hosted at https://myapp.com and it tries to fetch from https://api.myapp.com, the browser will block that request by default — different subdomain = different origin.
Why This Matters for APIs
When you separate your FE and BE (the modern pattern above), they almost always live on different domains:
- Frontend:
https://myapp.comorhttp://localhost:5173 - Backend:
https://api.myapp.comorhttp://localhost:3000
Without explicitly allowing it, the browser will block all requests from the FE to the BE.
Enabling CORS in Express
Install the CORS middleware package:
npm install cors
Allow all origins (fine for development):
const cors = require("cors");
app.use(cors());
Allow only your specific frontend in production:
app.use(cors({
origin: "https://myapp.com", // only requests from this domain are allowed
}));
Allow multiple specific origins:
app.use(cors({
origin: ["https://myapp.com", "https://admin.myapp.com"],
}));
CORS is enforced by the browser — it does not protect your API from server-to-server requests or tools like Postman. It only prevents unauthorized browser-based requests.
CORS on a Single Route
You can also apply CORS selectively to individual routes instead of the whole app:
const cors = require("cors");
// Only this route allows cross-origin requests
app.get("/public-data", cors(), (req, res) => {
res.json({ message: "Anyone can access this" });
});
// This route does not have CORS — only same-origin requests allowed
app.get("/private-data", (req, res) => {
res.json({ secret: "restricted" });
});
II. API Security
1. Session-based vs Token-based Auth
There are two main strategies for keeping a user "logged in" across requests.
a. Session-based (traditional)
- User logs in with username + password
- Server creates a session and stores it in a database
- Server sends back a cookie with the session ID
- Browser automatically includes that cookie on every subsequent request
- Server looks up the session ID in the database to verify the user
b. Token-based (modern API approach)
- User logs in with username + password
- Server creates a signed token and sends it back
- Client stores the token (in memory or localStorage)
- Client manually includes the token in the Authorization header of every subsequent request
- Server verifies the token's signature — no database lookup needed
Token auth is preferred for APIs because:
- FE and BE on different domains make cookies complicated (cookie scope is tied to domain)
- Tokens are stateless — the server doesn't need to store anything to verify them
- Works for any type of client — browser, mobile app, desktop app, other servers
- Tokens can be set to expire after a certain time for added security
2. JWT — JSON Web Token
A JWT is the most common type of token used for API auth. Structure: three base64-encoded parts joined by dots:
eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.abc123signature
↑ ↑ ↑
header payload signature
(algorithm) (your data, e.g. (proves it hasn't
userId, role) been tampered with)
- The header specifies the signing algorithm
- The payload contains claims — data you embed in the token (user ID, role, expiry)
- The signature is created by hashing header + payload with a secret key only the server knows
When the server receives a token, it re-hashes the header + payload with its secret key and checks if the signatures match. If they do, the token is valid and untampered. If someone modified the payload, the signature won't match — the server rejects it.
JWTs are not encrypted by default — the payload is just base64 encoded, which anyone can decode. Never put sensitive information (passwords, credit cards) in a JWT payload. They are signed (tamper-proof) but not secret.
Sending a token in a request:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VySWQiOjF9.abc123
The Bearer prefix is just a convention — it signals that what follows is a bearer token.
Token expiry:
const token = jwt.sign(
{ userId: user.id, role: user.role }, // payload
process.env.SECRET_KEY, // secret
{ expiresIn: "7d" } // expires in 7 days
);
Once expired, the token is rejected and the user must log in again — this limits the damage if a token is stolen.
3. Auth Middleware Pattern
The standard pattern is to create a middleware function that:
- Reads the token from the
Authorizationheader - Verifies it
- Attaches the decoded user data to
reqfor downstream use - Calls
next()to pass to the controller — or returns a 401 if invalid
const jwt = require("jsonwebtoken");
function requireAuth(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ error: "No token provided" });
}
const token = authHeader.split(" ")[1]; // extract token after "Bearer "
try {
const decoded = jwt.verify(token, process.env.SECRET_KEY);
req.user = decoded; // attach decoded payload to req for controllers to use
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
Apply it to any route that requires authentication:
// Public route — no auth needed
router.get("/posts", getAllPosts);
// Protected route — must be logged in
router.post("/posts", requireAuth, createPost);
router.delete("/posts/:id", requireAuth, deletePost);
Inside the controller, the user is available on req.user:
async function createPost(req, res) {
const { userId } = req.user; // set by requireAuth middleware
const { title, body } = req.body;
const post = await db.createPost({ title, body, authorId: userId });
res.status(201).json({ success: true, data: post });
}
4. Layered Auth — Roles and Tiers
In real apps, not all authenticated users have the same permissions. You can chain multiple middleware functions to enforce different tiers:
function requireAuth(req, res, next) {
// verify JWT, attach req.user
}
function requireAdmin(req, res, next) {
if (req.user.role !== "admin") {
return res.status(403).json({ error: "Forbidden" });
}
next();
}
// Anyone logged in can access this
router.get("/profile", requireAuth, getProfile);
// Only admins can access this
router.delete("/users/:id", requireAuth, requireAdmin, deleteUser);
- 401 Unauthorized — no valid token (not logged in)
- 403 Forbidden — valid token but insufficient permissions (logged in, but wrong role)
5. Signing and Verifying Tokens — Full Flow
const jwt = require("jsonwebtoken");
// On login — create and send the token
app.post("/login", async (req, res) => {
const { email, password } = req.body;
const user = await db.findUserByEmail(email);
if (!user || !bcrypt.compareSync(password, user.passwordHash)) {
return res.status(401).json({ error: "Invalid credentials" });
}
const token = jwt.sign(
{ userId: user.id, role: user.role },
process.env.SECRET_KEY,
{ expiresIn: "7d" }
);
res.json({ token }); // client stores this and sends it with future requests
});
// On a protected request — verify the token
function requireAuth(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "Unauthorized" });
try {
req.user = jwt.verify(token, process.env.SECRET_KEY);
next();
} catch {
res.status(401).json({ error: "Invalid or expired token" });
}
}